/*
 * Copyright (c) 2023 fred
 * Licensed under the Apache License,Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 *
 */
import { EntitiesData } from './EntitiesData'
import HashMap from '@ohos.util.HashMap'
import { ArrayUtils } from '../util/ArrayUtils'
import { OutputSettings } from './OutputSettings'
import { StringUtil } from '../internal/StringUtil'
import { Parser } from '../parser/Parser'
import { StringReader } from '../io/StringReader'
import { CharacterReader } from '../parser/CharacterReader'
import { Validate } from '../helper/Validate'
import hilog from '@ohos.hilog'
import { Character } from '../util/Character'
import { Syntax } from './Syntax'

/**
 * HTML entities, and escape routines. Source: <a href="http://www.w3.org/TR/html5/named-character-references.html#named-character-references">W3C
 * HTML named character references</a>.
 */
export class Entities {
  static readonly empty = -1
  static readonly emptyName = ""
  static readonly codepointRadix = 36
  private static readonly _codeDelims = ',;'
  // name -> multiple character references
  private static readonly _multipoints = new HashMap<string, string>()

  private constructor() {
  }

  /**
   * Check if the input is a known named entity
   *
   * @param name the possible entity name (e.g. "lt" or "amp")
   * @return true if a known named entity
   */
  public static isNamedEntity(name: string): boolean {
    return EscapeMode.extended.codepointForName(name) !== Entities.empty
  }

  /**
   * Check if the input is a known named entity in the base entity set.
   *
   * @param name the possible entity name (e.g. "lt" or "amp")
   * @return true if a known named entity in the base set
   * @see #isNamedEntity(String)
   */
  public static isBaseNamedEntity(name: string): boolean {
    return EscapeMode.base.codepointForName(name) != Entities.empty;
  }

  /**
   * Get the character(s) represented by the named entity
   *
   * @param name entity (e.g. "lt" or "amp")
   * @return the string value of the character(s) represented by this entity, or "" if not defined
   */
  public static getByName(name: string): string {
    let val = this._multipoints.get(name)
    if (val !== undefined && val !== null) {
      return val
    }
    let codepoint = EscapeMode.extended.codepointForName(name)
    if (codepoint !== Entities.empty) {
      return String.fromCharCode(codepoint)
    }
    return Entities.emptyName
  }

  public static codepointsForName(name: string, codepoints: number[]): number {
    let val = this._multipoints.get(name)
    if (val !== undefined && val !== null) {
      codepoints[0] = val.codePointAt(0)
      codepoints[1] = val.codePointAt(1)
      return 2
    }
    let codepoint = EscapeMode.extended.codepointForName(name)
    if (codepoint !== Entities.empty) {
      codepoints[0] = codepoint;
      return 1
    }
    return 0
  }

  /**
   * HTML escape an input string. That is, {@code <} is returned as {@code &lt;}
   *
   * @param string the un-escaped string to escape
   * @param out the output settings to use
   * @return the escaped string
   */
  public static escape(str: string, out?: OutputSettings):string {
    if (str === undefined || str === null) {
      return ""
    }
    let thisOut = out
    if (thisOut === undefined || thisOut === null) {
      if (Entities._DefaultOutput === undefined || Entities._DefaultOutput === null) {
        Entities._DefaultOutput = new OutputSettings()
      }
      thisOut = Entities._DefaultOutput
    }
    let accum = StringUtil.borrowBuilder()
    try {
      Entities.escape7(accum, str, out, false, false, false, false)
    } catch (e) {
      throw new Error('SerializationException')
    }
    return StringUtil.releaseBuilder(accum)
  }

  private static _DefaultOutput: OutputSettings; // lazy-init, to break circular dependency with OutputSettings

  // this method does a lot, but other breakups cause rescanning and stringbuilder generations
  public static escape7(accum: string[], str: string, out: OutputSettings, inAttribute: boolean, normaliseWhite: boolean, stripLeadingWhite: boolean, trimTrailing: boolean) {
    let lastWasWhite = false
    let reachedNonWhite = false
    let escapeMode = out.getEscapeMode()
    // final CharsetEncoder encoder = out.encoder();
    let coreCharset = out.coreCharset; // init in out.prepareEncoder()
    let length = str.length
    let codePoint: number
    let skipped = false
    for (let offset = 0;offset < length; offset+= Character.charCount(codePoint)) {
      codePoint = str.codePointAt(offset)
      if (normaliseWhite) {
        if (StringUtil.isWhitespace(codePoint)) {
          if (stripLeadingWhite && !reachedNonWhite) {
            continue
          }
          if (lastWasWhite) {
            continue
          }
          if (trimTrailing) {
            skipped = true
            continue
          }
          accum.push(' ')
          lastWasWhite = true
          continue
        } else {
          lastWasWhite = false
          reachedNonWhite = true
          if (skipped) {
            // wasn't the end, so need to place a normalized space
            accum.push(' ')
            skipped = false
          }
        }
      }
      // surrogate pairs, split implementation for efficiency on single char common case (saves creating strings, char[]):
      if (codePoint < Character.MIN_SUPPLEMENTARY_CODE_POINT) {
        switch (codePoint) {
          case '&'.charCodeAt(0):
            accum.push("&amp;")
            break
          case 0xA0:
            if (escapeMode !== EscapeMode.xhtml) {
              accum.push("&nbsp;")
            } else {
              accum.push("&#xa0;")
            }
            break
          case '<'.charCodeAt(0):
          // escape when in character data or when in a xml attribute val or XML syntax; not needed in html attr val
            if (!inAttribute || escapeMode === EscapeMode.xhtml || out.getSyntax() == Syntax.xml) {
              accum.push("&lt;")
            } else {
              accum.push(String.fromCharCode(codePoint))
            }
            break
          case '>'.charCodeAt(0):
            if (!inAttribute) {
              accum.push("&gt;")
            } else {
              accum.push(String.fromCodePoint(codePoint))
            }

            break
          case '"'.charCodeAt(0):
            if (inAttribute) {
              accum.push("&quot;")
            } else {
              accum.push(String.fromCodePoint(codePoint))
            }
            break
        // we escape ascii control <x20 (other than tab, line-feed, carriage return)  for XML compliance (required) and HTML ease of reading (not required) - https://www.w3.org/TR/xml/#charsets
          case 0x9:
          case 0xA:
          case 0xD:
            accum.push(String.fromCodePoint(codePoint))
            break;
          default:
            if (codePoint < 0x20 || !Entities.canEncode(coreCharset, codePoint /*, encoder*/)) {
              Entities.appendEncoded(accum, escapeMode, codePoint)
            } else {
              accum.push(String.fromCodePoint(codePoint))
            }
        }
      } else {
        // let c = new String(Character.toChars(codePoint));
        //         if (encoder.canEncode(c)) // uses fallback encoder for simplicity
        //             accum.append(c);
        //         else
        // Entities.appendEncoded(accum, escapeMode, codePoint)
        accum.push(String.fromCodePoint(codePoint))
      }
    }
  }

  private static appendEncoded(accum: string[], escapeMode: EscapeMode, codePoint: number) {
    let name = escapeMode.nameForCodepoint(codePoint)
    if (name !== Entities.emptyName) {
      accum.push('&', name, ';')
    } else {
      accum.push("&#x", codePoint.toString(16), ';')
    }
  }

  /**
   * Un-escape an HTML escaped string. That is, {@code &lt;} is returned as {@code <}.
   *
   * @param string the HTML string to un-escape
   * @return the unescaped string
   */
  static unescape(str: string, strict: boolean = false): string {
    return Parser.unescapeEntities(str, strict)
  }

  /*
     * Provides a fast-path for Encoder.canEncode, which drastically improves performance on Android post JellyBean.
     * After KitKat, the implementation of canEncode degrades to the point of being useless. For non ASCII or UTF,
     * performance may be bad. We can add more encoders for common character sets that are impacted by performance
     * issues on Android if required.
     *
     * Benchmarks:     *
     * OLD toHtml() impl v New (fastpath) in millis
     * Wiki: 1895, 16
     * CNN: 6378, 55
     * Alterslash: 3013, 28
     * Jsoup: 167, 2
     */
  private static canEncode(charset: CoreCharset, c: number): boolean {
    // todo add more charset tests if impacted by Android's bad perf in canEncode
    switch (charset) {
      case CoreCharset.ascii:
        return c < 0x80
      case CoreCharset.utf:
        return true
    }
    return false
  }

  static load(e: EscapeMode, pointsData: string, size: number) {
    e._nameKeys = []
    e._codeVals = []
    e._codeKeys = []
    e._nameVals = []
    let i = 0
    hilog.info(0x0000, 'testTag', '%{public}s', `load aaaaa pd type:${typeof pointsData}`);
    let reader = new CharacterReader(pointsData,1024 * 32)
    hilog.info(0x0000, 'testTag', '%{public}s', `load aaaaabbbb`);
    try {
      while (!reader.isEmpty()) {
        // NotNestedLessLess=10913,824;1887&
        let name = reader.consumeTo('='.charCodeAt(0))
        reader.advance()
        let cp1 = parseInt(reader.consumeToAny(...Entities._codeDelims), Entities.codepointRadix)
        let codeDelim = reader.current()
        reader.advance()
        let cp2: number
        if (codeDelim === ',') {
          cp2 = parseInt(reader.consumeTo(';'.charCodeAt(0)), Entities.codepointRadix)
          reader.advance()
        } else {
          cp2 = Entities.empty
        }
        let indexS = reader.consumeTo('&'.charCodeAt(0))
        try {
          let index = parseInt(indexS, Entities.codepointRadix)
          reader.advance()
          e._nameKeys[i] = name;
          e._codeVals[i] = cp1;
          e._codeKeys[index] = cp1;
          e._nameVals[index] = name;
          if (cp2 !== Entities.empty) {
            this._multipoints.set(name, String.fromCharCode(cp1, cp2))
          }

        } catch (e) {
        }
        i++
      }
    } finally {
      reader.close()
    }
  }

  public static byName(name: string): CoreCharset {
    if (name.toUpperCase().indexOf('ASCII') >= 0) {
      return CoreCharset.ascii
    }
    // covers UTF-8, UTF-16, et al
    if (name.toUpperCase().startsWith("UTF")) {
      return CoreCharset.utf
    }
    return CoreCharset.fallback;
  }
}

export class EscapeMode {
  /**
   * Restricted entities suitable for XHTML output: lt, gt, amp, and quot only.
   */
  public static readonly xhtml = new EscapeMode(EntitiesData.xmlPoints, 4)
  /**
   * Default HTML output entities.
   */
  public static readonly base = new EscapeMode(EntitiesData.basePoints, 106)
  /**
   * Complete HTML entities.
   */
  public static readonly extended = new EscapeMode(EntitiesData.fullPoints, 2125)

  // table of named references to their codepoints. sorted so we can binary search. built by BuildEntities.
  _nameKeys: string[]
  // limitation is the few references with multiple characters; those go into multipoints.
  _codeVals: number[]
  // table of codepoints to named entities.
  // we don't support multicodepoints to single named value currently
  _codeKeys: number[]
  _nameVals: string[]

  constructor(file: string, size: number) {
    Entities.load(this, file, size)
  }

  codepointForName(name: string): number {
    let index = ArrayUtils.binarySearch(this._nameKeys, name)
    return index >= 0 ? this._codeVals[index] : Entities.empty
  }

  nameForCodepoint(codepoint: number): string {
    let index = ArrayUtils.binarySearch(this._codeKeys, codepoint)
    if (index >= 0) {
      // the results are ordered so lower case versions of same codepoint come after uppercase, and we prefer to emit lower
      // (and binary search for same item with multi results is undefined
      return (index < this._nameVals.length - 1 && this._codeKeys[index + 1] === codepoint) ? this._nameVals[index + 1]
                                                                                            : this._nameVals[index];
    }
    return Entities.emptyName
  }
}

export enum CoreCharset {
  ascii, utf, fallback
}
